Skip to content

Release January 20, 2026 - #2582

Open
github-actions[bot] wants to merge 287 commits into
mainfrom
changeset-release/from-canary
Open

Release January 20, 2026#2582
github-actions[bot] wants to merge 287 commits into
mainfrom
changeset-release/from-canary

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated release PR from canary branch

@vercel

vercel Bot commented Jan 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
graphcommerce-246 Ready Ready Preview Aug 7, 2026 12:26pm
graphcommerce-247 Ready Ready Preview Aug 7, 2026 12:26pm
graphcommerce-hygraph-dynamic-rows-ui Ready Ready Preview Aug 7, 2026 12:26pm
graphcommerce-open-source Ready Ready Preview Aug 7, 2026 12:26pm
graphcommerce-storyblok Ready Ready Preview Aug 7, 2026 12:26pm

Request Review

Slashes aren’t valid in Story slugs
…Storyblok Visual Editor

Just returns the story as is outside of the visuel editor
Unnecessary as Storyblok already has a Slug field which does the same
RowPdp contains rows that are soly ment for product detail pages and require product data
RowProduct contains rows that are ment to display products in grids, swipers, etc on any page
…ing & widen RowRenderer content type

- useStoryblokState now narrows to StoryblokPage; global-config uses the upstream
  hook directly via the typed GlobalConfigStory returned by fetchGlobalConfig
- RowRenderer accepts a wider content type so typed StoryblokPage body unions
  ([k: string]: unknown) are assignable
- fetchGlobalConfig and useStoryblokState throw a descriptive error in dev when
  content type doesn't match, return null in production
- fetchStory/fetchStories log non-404 errors in dev so real failures (network,
  auth, malformed) are distinguishable from missing stories
to make sure all pages get fetched when passing the 100 story per fetch API limit

- Fetch page 1, then derive remaining pages from response.total and fetch them
  in parallel chunks of 3 so large slug prefixes aren't capped at 100 stories
- Add fetchWithRetry: retries 429s up to 3 times honoring the Retry-After
  header, lets all other errors propagate to the existing logFetchError path
Wrap the single-story fetch in fetchWithRetry so 429s during builds with many
slugs are retried with Retry-After, matching fetchStories' behavior.
…pages

- RowProduct accepts an optional items prop that bypasses the SKU resolve
  query, used when the parent already has products to surface
- Category page wires a row_product renderer override that passes the first
  8 category products, mirroring the Hygraph pattern
hsngdz and others added 30 commits August 5, 2026 15:43
…routes

Magento renders links from base_link_url, which on a headless setup points
at the GraphCommerce storefront. Every stock email template contains a
customer/account/ link ("Sign in to your account") and gated Magento routes
302 to customer/account/login — GraphCommerce serves none of those, so
customers landed on a 404.

withGraphCommerce now adds permanent redirects for /customer/account,
/customer/account/index, /customer/account/login, /customer/account/create,
/customer/account/forgotpassword and /sales/order/history. They are exact
matches on purpose: redirects run before the filesystem routes, so a
/customer/account/:path* catch-all would shadow the
pages/customer/account/{confirm,createPassword} routes that
@graphcommerce/magento-customer copies into the project.

This restores the /customer/account redirect that was dropped as collateral
in "Remove redirects for /product/$type/[url] routes".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`multilinkHref` preferred the multilink's `cached_url`, which Storyblok freezes
at the moment the *referencing* story is published. Renaming the target story
does not rewrite it in already-published content, so every link to the renamed
story kept emitting the old URL — a 404 — until an editor re-published each
referencing story.

`story.full_slug` is injected by `@storyblok/react` from the `links` map the CDN
builds from the live story index on every request, and `sbParams` already sends
`resolve_links: 'story'` on all reads, so it is populated for story links and
always current. Prefer it, and fall through unchanged for `url` / `email` /
`asset` links, which have no resolved story.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three sources of avoidable requests in the storefront's Storyblok layer:

- `refreshStoryblokCacheVersion()` issued `cdn/spaces/me` and expected
  `storyblok-js-client` to pick the new cache-version up, but the client only
  advances its pin from a response carrying a top-level `cv` — `cdn/spaces/me`
  answers with `{ space: { version } }`. The pin therefore never moved and the
  request was pure overhead. Read the version and apply it explicitly, and flush
  the client's response cache when it changed so opt-in `apiOptions.cache` users
  cannot serve content that outlived its version.
- `fetchStory()` had no memory of 404s. The client caches successful responses
  only, so a catch-all route that probes the CMS for every category URL before
  falling back re-asked the API on every render for slugs that never have a
  story. Remember a 404 for one cache-version interval; draft/preview reads and
  development bypass it, and only a genuine 404 is remembered so network and auth
  failures stay retryable.
- `getStoryblokStaticPaths()` downloaded every story body to read `full_slug`.
  The content sitemap that calls it runs in `getServerSideProps`, so that was the
  whole space per crawler hit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: the entry had no evidence behind it. Grepping every stock
Magento email template shows only three customer URLs — customer/account/
(48x), customer/account/createPassword (12x, carries id + rp_token) and
customer/account/confirm (2x, carries id + key). forgotpassword appears in
none of them; Magento's reset token rides on createPassword, and
forgotpassword is just the form where a customer types their email.

The remaining five are re-audited for shadowing. GraphCommerce serves exactly
two paths under /customer: confirm and createPassword, both real pages in all
three examples. None of the five sources collide with them (/customer/account/
create is an exact match and does not cover createPassword), and the comment
now states the mechanism that makes this matter: a redirect wins over a
filesystem route, so redirecting a path that has a page makes that page
unreachable.

Also corrects the comment's claim that @graphcommerce/magento-customer copies
those pages in — it has no copy/ directory; they are scaffolded from the
examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…refer-resolved-full-slug

fix(storyblok-ui): resolve story multilinks to the target's current slug
…omer-account-urls

fix(next-config): redirect Magento account URLs to the GraphCommerce routes
Review feedback: remembering individual 404s does not help against the case it
most needs to — crawlers and vulnerability scanners walk an unbounded number of
made-up URLs, and a catch-all route asks the CMS about every one of them. Every
made-up URL is a fresh slug, so a per-slug negative cache never gets a second
chance to help and each one still costs a CDN request.

Replace it with a slug index: one `cdn/links` request per cache-version lists
every published slug in the space, and anything outside that set is answered as
"not found" without a request. Measured against a live space: 300 random URLs
now cost 0 requests instead of 300, while known slugs are unaffected.

- Deliberately unscoped by language. `cdn/links?language=x` returns only the
  stories translated into that language (25 of 63 on the space I tested, for a
  language the space does not even define), while `cdn/stories/<slug>?language=x`
  falls back to the default language and resolves all of them — so a
  language-scoped index would report existing pages as missing.
- Folders are skipped. A folder with a start page contributes that start page as
  its own entry (`clubkleding/`), which is what makes `fetchStory('clubkleding')`
  resolve while a folder without one correctly reports as missing.
- Memoized per cache-version in module scope rather than through the client's
  response cache, because that cache is opt-in and off by default.
- Never claims absence when it does not know: a failed index request, or a space
  larger than 10 000 stories, falls back to the previous per-slug behaviour.
  Draft/preview reads and development skip the index entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`requestStoryblokCacheVersionRefresh()` — what a cache-notify webhook calls when a
story is published — only flipped a flag in the process that happened to receive
the webhook. Nothing told the other replicas anything had changed, so the only
thing keeping them fresh was `cacheVersionTtl`: one `cdn/spaces/me` per server per
interval, forever, whether or not anything is ever published. At the previous 60
second default that is ~450 000 requests a month across a ten-pod deployment, spent
entirely on discovering that nothing happened.

The refresh now fans out over a file instead. The webhook writes a millisecond
timestamp to `renew-all-pages-query.txt` and every server compares it against the
last value it acted on before a published read — a few bytes off the filesystem,
throttled to once a second, never an API request. A server that sees a newer signal
fetches the current cache-version once and pins it, so the cost of a publish is one
request per server that actually serves traffic, instead of one request per server
per minute forever.

- The file name is not new: it is the one the SSR Apollo client convention already
  reads (`examples/*/lib/graphql/graphqlSsrClient.ts` drops its per-locale client
  when the timestamp moves past its own), so one publish can invalidate both caches
  and the contract stays "this file contains a number".
- `storyblok.cacheVersionSignalDir` says where that file lives. It defaults to the
  `./tmp` of the existing convention, which resolves *inside each container* — a
  multi-replica deployment has to point it at a shared volume or the signal never
  leaves the pod that wrote it. `renewSignalPath()` is exported so a project's
  `graphqlSsrClient` can read the same file without hardcoding the path twice.
- `cacheVersionTtl` now defaults to 3600 rather than 60. It is no longer what keeps
  content fresh, only what bounds staleness where the signal cannot be delivered:
  serverless, local development, an unwritable directory. It stays applied rather
  than disabled on purpose — a misconfigured webhook has to degrade to stale
  content, never to frozen content.
- The real cache-version is still fetched rather than derived from the signal's
  timestamp. Storyblok answers a request carrying an unknown `cv` with a 301 to the
  canonical one, so a made-up value would work, but it costs every replica a
  redirect round-trip on its next read and keys the slug index on a version that is
  about to be replaced.
- `node:fs` is loaded through a `webpackIgnore`d dynamic import. This module is
  re-exported from the package's single barrel entry point and Next.js does not
  tree-shake in development, so a static import would break every client component
  importing from the package.

Draft, preview and development reads are untouched — they already send a fresh `cv`
per request.

Verified with ten real OS processes against one shared directory: before, a publish
reached 1 of 10 processes and the other nine kept serving the pre-publish version;
after, all 10 picked it up within a second, at one `cdn/spaces/me` each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useClearCurrentCartId()` returned a bare arrow function and `useCartLock()`
defined `lock`/`unlock` inline, so all three got a new identity on every
render. Consumers that correctly list them in a `useEffect` dependency array
therefore re-ran that effect on every render.

`useClearCurrentCartId` now uses `useCallback` with `[cache]`, matching its
neighbour `useAssignCurrentCartId`. `lock`/`unlock` use `useEventCallback`
because they read values that legitimately change (`currentCartId`, the
router query state, the Apollo client); that keeps the identity stable
without introducing a stale closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…identities

fix(magento-cart): stable identities for cart clear and cart lock hooks
The renew signal was a file in a configurable directory, which only fans out
when every replica happens to mount the same volume — the constraint that makes
the `./tmp` convention in `examples/*/lib/graphql/graphqlSsrClient.ts` unreliable
in the first place. `storyblok.cacheVersionSignalDir` made that a supported
option rather than fixing it, so both the config key and the filesystem access
are gone.

The signal now goes through `globalThis.__incrementalCache`, so it reaches every
server that shares a `cacheHandler` — the same requirement a deployment already
has to meet to serve consistent ISR, and nothing extra to configure. Validated on
a two-pod cluster: pod A writes, pod B reads the value within 9 ms.

This leans on two Next.js internals. The global is set in `handleRequestImpl`
(`next/dist/server/base-server.js`) on every incoming request, ahead of API
routes and ISR regeneration, under the same name and shape in Next 13 through 16;
middleware and the edge runtime build their own instance and are not covered.
Writing under `kind: 'FETCH'` with `fetchCache: true` is the one path that stores
a key verbatim rather than through `normalizePagePath()`, which is what allows a
namespaced key. Both are unstable: if either changes, reads and writes fail
closed and freshness falls back to `cacheVersionTtl`. The call shape has drifted
before — `revalidate` became `cacheControl` between Next 14 and 15 — so it needs
re-testing per major.

Why the push is worth having at all: `cdn/spaces/me` polls are billed per request
against the Storyblok quota, so the polling interval is a cost, not just a
staleness knob. `cacheVersionTtl` stays at 3600 as the failsafe.

Also drops the justification prose from the comments and rewrites both changesets
to describe what changes rather than argue for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blok

Two mechanisms were solving the same problem in different ways: Storyblok
pushed a renew signal through the incremental cache, while `graphqlSsrClient()`
read `./tmp/renew-all-pages-query.txt`. That path resolves inside each
container, so on a multi-replica deployment it silently invalidated nothing —
the assumption that made the file approach wrong for Storyblok in the first
place.

The signal moves to `@graphcommerce/graphql` as `publishRenewSignal()` /
`renewSignal()` / `refreshRenewSignal()`. That package is the right home by
dependency direction: `storyblok-ui` peer-depends on it, it does not depend on
`storyblok-ui`, and all three examples already import from it. The key is
neutral (`gc:signal:content-renew`) because the fact being published is "content
changed", not anything Storyblok-specific.

`graphqlSsrClient()` is synchronous and called as
`const client = graphqlSsrClient(context)` throughout `getStaticProps`, so it
cannot await the read; making it async would break every consumer in every
project. It reads the last known value and schedules the next refresh, which
bounds staleness at one poll interval plus one call.

That makes the cold start explicit rather than accidental. `renewSignal()`
returns `undefined` until the first read completes, which is distinct from "read,
and nothing was published" — every pod is in that state right after a deploy, and
treating it as 0 would silently skip the first invalidation. Callers create the
client without invalidating (a client made now cannot predate a publish) and let
the scheduled read decide the next call. Concurrent callers share one in-flight
read, so a fresh pod taking simultaneous requests does not open one per
`getStaticProps`.

Also sets the `cacheVersionTtl` schema default to 3600 to match the repo
convention. The `?? 3600` fallback stays: schema defaults are applied by the zod
parse but not carried into the generated config values, which is what the runtime
imports — `configurableVariantForSimple` has a `= false` default and still
generates `undefined`.

Verified against a stand-in incremental cache: 25 concurrent refreshes cost one
read, a second process picks up a publish, and the client is recreated once per
publish and stable in between.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er-api-calls

fix(storyblok-ui): cut redundant Storyblok CDN requests
…ignment

feat!: GraphCommerce 11 — drop Node 20, require Node 22+
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants